濕壁畫(fresco)有一個殘酷的物理限制:
灰泥抹上牆的那一刻起,趁灰泥還沒乾透之前完成筆觸,畫家只有幾個小時可以動作
灰泥一乾,顏料就再也吃不進去了;畫錯一筆,就沒有回頭路了1508 年,米開朗基羅接下西斯汀教堂天頂畫的委託
整片天頂,分成幾百個「giornata」意指「一天的量」,指一次施工能完成的灰泥面積每一個 giornata 動工前,必須先想清楚這一小塊要畫什麼、跟旁邊怎麼銜接
一旦灰泥抹上去,這塊區域的設計,就定案了
模組一談的是「體積太大」,模組二談的是「結構用錯」
模組三談的是完全不同的東西:改一個需求,為什麼要付出遠超預期的代價?
三個「凝固」警訊:
| Day | Code Smell | 一句話定位 |
|---|---|---|
| 15 | 發散式變更 (Divergent Change) | 一個類別,因為好幾個互不相關的理由被頻繁修改 |
| 16 | 霰彈式修改 (Shotgun Surgery) | 一個小需求,卻要在好幾個類別裡各補一槍 |
| 17 | 平行繼承體系 (Parallel Inheritance Hierarchies) | 兩套繼承結構,像鏡子一樣必須同步增減 |
第一站,從一塊被迫回應太多種天氣的灰泥開始
同一塊 giornata 上,如果同時被要求:
三件互不相關的事,逼著畫家在同一塊灰泥上,反覆修改
而灰泥的乾燥時間有限,每一次修改都在跟時間賽跑,修改的理由越多元,這塊區域就越危險
模組一到模組二,OrderProcessor 被拆得很乾淨了
團隊接到新需求:「每月要產出一份訂單彙總報表,寄給營運主管」
有人很快寫出了 OrderReport:
public class OrderReport
{
private readonly AppDbContext _db;
public OrderReport(AppDbContext db) => _db = db;
public string GenerateMonthlyReport(int year, int month)
{
// 職責一:從資料庫撈資料
var orders = _db.Orders
.Where(o => o.CreatedAt.Year == year && o.CreatedAt.Month == month)
.ToList();
// 職責二:計算業務數字
decimal totalRevenue = orders.Sum(o => o.Price);
decimal totalTax = totalRevenue * 0.05m;
var vipOrders = orders.Where(o => o.CustomerTier == CustomerTier.Vip).Count();
// 職責三:格式化成 HTML 郵件內容
var html = $"<h1>{year} 年 {month} 月訂單報表</h1>";
html += $"<p>總營收:{totalRevenue:C}</p>";
html += $"<p>營業稅:{totalTax:C}</p>";
html += $"<p>VIP 訂單數:{vipOrders}</p>";
return html;
}
}
能動,也不長,在半年前,這種寫法我們可能不會多想
問題不在「這個方法太長」,它其實不到 20 行
問題在於 OrderReport 會因為三種互不相關的理由,被反覆修改
Orders 表新增了一個折扣欄位),要改資料撈取的那一段三條完全不相干的故事線,擠在同一個檔案裡
這跟 Day 04 的巨大類別很像,但判斷的角度不一樣。Day 04 問的是「這個類別做了幾件事」
今天要問的是更精確的問題:「這個類別,會因為幾種不同的理由被修改?」
負責「調整稅率」的人,跟負責「改報表版型」的人,很可能會同時打開同一個檔案,甚至衝突在同一段程式碼附近
解法跟 Day 04 一樣是提煉類別 (Extract Class)
但這次的切分依據更明確,每一種「被修改的理由」,都值得有自己的家
public class OrderDataFetcher
{
private readonly AppDbContext _db;
public OrderDataFetcher(AppDbContext db) => _db = db;
public List<Order> FetchByMonth(int year, int month) =>
_db.Orders.Where(o => o.CreatedAt.Year == year && o.CreatedAt.Month == month).ToList();
}
public class OrderReportCalculator
{
public ReportSummary Calculate(List<Order> orders) => new ReportSummary
{
TotalRevenue = orders.Sum(o => o.Price),
TotalTax = orders.Sum(o => o.Price) * 0.05m,
VipOrderCount = orders.Count(o => o.CustomerTier == CustomerTier.Vip)
};
}
public class OrderReportHtmlFormatter
{
public string Format(int year, int month, ReportSummary summary) =>
$"<h1>{year} 年 {month} 月訂單報表</h1>" +
$"<p>總營收:{summary.TotalRevenue:C}</p>" +
$"<p>營業稅:{summary.TotalTax:C}</p>" +
$"<p>VIP 訂單數:{summary.VipOrderCount}</p>";
}
OrderReport 縮回成一個薄薄的協調者:
public class OrderReport
{
private readonly OrderDataFetcher _fetcher;
private readonly OrderReportCalculator _calculator;
private readonly OrderReportHtmlFormatter _formatter;
public OrderReport(OrderDataFetcher fetcher, OrderReportCalculator calculator,
OrderReportHtmlFormatter formatter)
{
_fetcher = fetcher;
_calculator = calculator;
_formatter = formatter;
}
public string GenerateMonthlyReport(int year, int month)
{
var orders = _fetcher.FetchByMonth(year, month);
var summary = _calculator.Calculate(orders);
return _formatter.Format(year, month, summary);
}
}
現在調稅率的人,只會動到 OrderReportCalculator
改版型的人,只會動到 OrderReportHtmlFormatter
兩個人不會再搶同一段程式碼
半年後主管想加 PDF 輸出,只要新增一個 OrderReportPdfFormatterOrderDataFetcher 跟 OrderReportCalculator,一行都不用動
不是每一次拆分都值得做,判斷的重點是:
只要答案是肯定的,這個類別大概已經在同一塊灰泥上,被迫回應太多種天氣了
明天我們看發散式變更的鏡像問題:
「不是一個類別因為太多理由被改,是一個單純的需求,卻要在好幾個類別裡各補一槍」
模組三第二站:霰彈式修改(Shotgun Surgery)